Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support Smithy default trait #857

Merged
merged 38 commits into from
Jun 16, 2023
Merged

feat: support Smithy default trait #857

merged 38 commits into from
Jun 16, 2023

Conversation

lauzadis
Copy link
Contributor

Adds support for the default trait.

Issue #

#718

Description of changes

Support for the default trait is required for supporting Smithy IDL 2.0.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@lauzadis lauzadis marked this pull request as ready for review May 24, 2023 17:13
@lauzadis lauzadis requested a review from a team as a code owner May 24, 2023 17:13
Comment on lines 175 to 194
val targetSymbol = if (nullableIndex.isMemberNullable(shape, NullableIndex.CheckMode.CLIENT_ZERO_VALUE_V1_NO_INPUT)) {
var targetSymbol = if (nullableIndex.isMemberNullable(shape, NullableIndex.CheckMode.CLIENT)) {
toSymbol(targetShape).toBuilder().boxed().build()
} else {
toSymbol(targetShape)
}

targetSymbol = shape.getTrait<DefaultTrait>()?.let {
val builder = targetSymbol.toBuilder()
val defaultValue = it.getDefaultValue(targetShape)
builder.defaultValue(defaultValue)
if (defaultValue != "null") {
builder.unboxed()
}
builder.build()
} ?: targetSymbol
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The var reference here is unnecessary. You're already dealing with builders is several places so I suggest just keeping it as a builder until you've applied all the customizations necessary:

val targetSymbol = toSymbol(targetShape)
    .toBuilder()
    .apply {
        if (nullableIndex.isMemberNullable(shape, NullableIndex.CheckMode.CLIENT)) boxed()

        shape.getTrait<DefaultTrait>()?.getDefaultValue(targetShape)?.let {
            defaultValue(it)
            if (it != "null") unboxed()
        }
    }
    .build()

Comment on lines 175 to 194
val targetSymbol = if (nullableIndex.isMemberNullable(shape, NullableIndex.CheckMode.CLIENT_ZERO_VALUE_V1_NO_INPUT)) {
var targetSymbol = if (nullableIndex.isMemberNullable(shape, NullableIndex.CheckMode.CLIENT)) {
toSymbol(targetShape).toBuilder().boxed().build()
} else {
toSymbol(targetShape)
}

targetSymbol = shape.getTrait<DefaultTrait>()?.let {
val builder = targetSymbol.toBuilder()
val defaultValue = it.getDefaultValue(targetShape)
builder.defaultValue(defaultValue)
if (defaultValue != "null") {
builder.unboxed()
}
builder.build()
} ?: targetSymbol
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Looks like we box when the nullable index says to but then unbox if the default isn't null. Is that right? What happens if these two things conflict?

Comment on lines 216 to 229
private fun DefaultTrait.getDefaultValue(targetShape: Shape): String =
if (toNode().toString() == "null" || (targetShape is BlobShape && toNode().toString() == "")) {
"null"
} else if (toNode().isNumberNode) {
getDefaultValueForNumber(targetShape, toNode().toString())
} else if (toNode().isArrayNode) {
"listOf()"
} else if (toNode().isObjectNode) {
"mapOf()"
} else if (toNode().isStringNode) {
"\"${toNode()}\""
} else {
toNode().toString()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style: Prefer when over if/else if/.../else.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@@ -253,6 +284,13 @@ class KotlinSymbolProvider(private val model: Model, private val settings: Kotli
return builder
}

private fun getDefaultValueForNumber(shape: Shape, value: String) = when (shape) {
is LongShape -> "${value}L"
is FloatShape -> if (value.matches("[0-9]*\\.[0-9]+".toRegex())) "${value}f" else "$value.0f"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Is the decimal check necessary? The f type specifier should mean that a decimal component is not required (i.e., 5f and 5.0f are identical).

/**
* Mark a symbol as not being boxed
*/
fun Symbol.Builder.unboxed(): Symbol.Builder = apply { removeProperty(SymbolProperty.BOXED_KEY) }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Should we be keeping the notion of boxed/unboxed in our codebase at this point? It seems like the nullability index should be preferred at this point, should we rename this or change the way we represent symbol nullability?

Comment on lines 216 to 229
private fun DefaultTrait.getDefaultValue(targetShape: Shape): String =
if (toNode().toString() == "null" || (targetShape is BlobShape && toNode().toString() == "")) {
"null"
} else if (toNode().isNumberNode) {
getDefaultValueForNumber(targetShape, toNode().toString())
} else if (toNode().isArrayNode) {
"listOf()"
} else if (toNode().isObjectNode) {
"mapOf()"
} else if (toNode().isStringNode) {
"\"${toNode()}\""
} else {
toNode().toString()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Contributor

@ianbotsf ianbotsf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks mostly good, few minor things/questions.

toNode().isNumberNode -> getDefaultValueForNumber(targetShape, toNode().toString())
toNode().isArrayNode -> "listOf()"
toNode().isObjectNode -> "mapOf()"
toNode().isStringNode -> "\"${toNode()}\""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This doesn't escape any quotes in the value. While unlikely, it's still better to be safe. Fortunately, we already have a String.dq() extension method for exactly this:

toNode().isStringNode -> toNode().toString().dq()

val default = getProperty(SymbolProperty.DEFAULT_VALUE_KEY, String::class.java)
return if (default.isPresent) default.get() else null
val defaultType = default.getOrNull()?.run { getProperty(SymbolProperty.DEFAULT_VALUE_TYPE_KEY, DefaultValueType::class.java).get() }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style: I suggest a convenience extension property for getting the default type, similar to isNullable.

long MyFoo
""".prependNamespaceAndService().toSmithyModel()
""".prependNamespaceAndService(version = "2").toSmithyModel()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Should version = "2" be the default?

if (nullableIndex.isMemberNullable(shape, NullableIndex.CheckMode.CLIENT_ZERO_VALUE_V1_NO_INPUT)) nullable()

shape.getTrait<DefaultTrait>()?.let {
defaultValue(it.getDefaultValue(targetShape), DefaultValueType.MODELED)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so if a member has @default(null) in the model ("to explicitly indicate that the member has no default value or to override the default value requirement of a targeted shape."), this will be treated as having a MODELED default, even though semantically it makes the member nullable. So it would say nullable=true and defaultValueType=MODELED? I'm still trying to understand how MODELED is used, but just checking if this is ok/intended?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@default(null) will not make the member nullable, it will just override any default value which has been potentially set by a target shape. the only thing that makes members nullable is the nullability index provided by Smithy. My other response has an explanation of INFERRED vs. MODELED

createSymbolBuilder(shape, typeName, namespace = "kotlin")
.defaultValue(defaultValue)
.build()
createSymbolBuilder(shape, typeName, namespace = "kotlin").defaultValue(defaultValue).build()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: so all number shapes having a INFERRED default value means the shape knows what the value would be, but the shape can be nullable too, and it's the nullable check that'll decide how the code is generated, and if it is not nullable, only then that defaultValue is relevant? Trying to understand the motivation for maintaining INFERRED v/s MODELED.

Copy link
Contributor Author

@lauzadis lauzadis Jun 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The motivation behind INFERRED / MODELED is because we're using .defaultValue() in two ways now. The previous way is inferring that numbers and booleans have a default value (0, 0f, 0.0, false). The new way we use .defaultValue() is when it's modeled on a shape.

If a shape has an INFERRED default value but it's nullable, we set it to null. If a shape has a MODELED default value but it's nullable, we will use the default value.

val default = getProperty(SymbolProperty.DEFAULT_VALUE_KEY, String::class.java)
return if (default.isPresent) default.get() else null

// nullable types should default to null if there is no modeled default
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I probably don't understand how this all fits, but why is isNullable not sufficient? Seems simpler if it ended up like that. What test case would break without the && check?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for the && check is if something's nullable but it has a default value modeled, we want to write that default.

With && check:

public val foo: String? = "my modeled default value"

Without && check:

public val foo: String? = null // (discards the modeled default value)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought if something has a non-null default value, it won't be nullable. Atleast when it uses CLIENT mode, per https://github.com/awslabs/smithy/blob/b69aeb5dae6639b4381575005722bea02bb31ca8/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/NullableIndex.java#L142.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, but they can be nullable because of @clientOptional or @input and still have default value. got it.

val provider: SymbolProvider = KotlinCodegenPlugin.createSymbolProvider(model)
val member = model.expectShape<MemberShape>("com.test#MyStruct\$foo")
val memberSymbol = provider.toSymbol(member)
assertEquals("kotlin", memberSymbol.namespace)
assertEquals("null", memberSymbol.defaultValue())
assertTrue(memberSymbol.isBoxed)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems useful to assert memberSymbol.isNullable to show the behavior.

import software.amazon.smithy.kotlin.codegen.model.traits.SYNTHETIC_NAMESPACE
import software.amazon.smithy.kotlin.codegen.test.*
import software.amazon.smithy.model.shapes.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class SymbolProviderTest {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably worth adding cases with @clientOptional, @addedDefault, @input?

And @required on nullable target shapes? like

structure MyStruct {
    @required
    quux: Integer,
}

val targetSymbol = toSymbol(targetShape)
.toBuilder()
.apply {
if (nullableIndex.isMemberNullable(shape, NullableIndex.CheckMode.CLIENT_ZERO_VALUE_V1_NO_INPUT)) nullable()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given kotlin SDK is not GA, could NullableIndex.CheckMode.CLIENT be used?

And note, nullableIndex.isMemberNullable(shape) uses CheckMode.CLIENT.

Copy link
Contributor Author

@lauzadis lauzadis Jun 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're stuck using CLIENT_ZERO_VALUE_V1_NO_INPUT until S3 makes all their numbers and booleans nullable. We tried changing it in this PR but faced some issues with @default and nullability on things like PutObjectRequest's bucketKeyEnabled.

Internal reference: P83747977

We can hold off on merging this PR until it's resolved internally, and then I can update to use CheckMode.CLIENT

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it. if this needs to be merged sooner for some reason, as long as there's tracking / TODO somewhere to change to CLIENT that sounds fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spoke with the team, we'll merge the PR now and add a tracking item to update to CLIENT

@sonarqubecloud
Copy link

Kudos, SonarCloud Quality Gate passed!    Quality Gate passed

Bug A 0 Bugs
Vulnerability A 0 Vulnerabilities
Security Hotspot A 0 Security Hotspots
Code Smell A 14 Code Smells

No Coverage information No Coverage information
0.0% 0.0% Duplication

@lauzadis lauzadis merged commit 87dd1c2 into main Jun 16, 2023
@lauzadis lauzadis deleted the feat-default-trait branch June 16, 2023 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants